import { AgEvent, AgEventListener, Events, RowEvent, RowSelectedEvent, SelectionEventSourceType } from "../events";
import { EventService } from "../eventService";
import { DetailGridInfo } from "../gridApi";
import { IClientSideRowModel } from "../interfaces/iClientSideRowModel";
import { IEventEmitter } from "../interfaces/iEventEmitter";
import { IServerSideRowModel } from "../interfaces/iServerSideRowModel";
import { IServerSideStore } from "../interfaces/IServerSideStore";
import { Beans } from "../rendering/beans";
import { debounce } from "../utils/function";
import { exists, missing, missingOrEmpty } from "../utils/generic";
import { Column } from "./column";
import { CellChangedEvent, DataChangedEvent, IRowNode, RowHighlightPosition, RowNodeEvent, RowNodeEventType, RowPinnedType, SetSelectedParams } from "../interfaces/iRowNode";
import { CellEditRequestEvent } from "../events";
import { FrameworkEventListenerService } from "../misc/frameworkEventListenerService";

export class RowNode<TData = any> implements IEventEmitter, IRowNode<TData> {

    public static ID_PREFIX_ROW_GROUP = 'row-group-';
    public static ID_PREFIX_TOP_PINNED = 't-';
    public static ID_PREFIX_BOTTOM_PINNED = 'b-';

    private static OBJECT_ID_SEQUENCE = 0;

    public static EVENT_ROW_SELECTED: RowNodeEventType = 'rowSelected';
    public static EVENT_DATA_CHANGED: RowNodeEventType = 'dataChanged';
    public static EVENT_CELL_CHANGED: RowNodeEventType = 'cellChanged';
    public static EVENT_ALL_CHILDREN_COUNT_CHANGED: RowNodeEventType = 'allChildrenCountChanged';
    public static EVENT_MASTER_CHANGED: RowNodeEventType = 'masterChanged';
    public static EVENT_GROUP_CHANGED: RowNodeEventType = 'groupChanged';
    public static EVENT_MOUSE_ENTER: RowNodeEventType = 'mouseEnter';
    public static EVENT_MOUSE_LEAVE: RowNodeEventType = 'mouseLeave';
    public static EVENT_HEIGHT_CHANGED: RowNodeEventType = 'heightChanged';
    public static EVENT_TOP_CHANGED: RowNodeEventType = 'topChanged';
    public static EVENT_DISPLAYED_CHANGED: RowNodeEventType = 'displayedChanged';
    public static EVENT_FIRST_CHILD_CHANGED: RowNodeEventType = 'firstChildChanged';
    public static EVENT_LAST_CHILD_CHANGED: RowNodeEventType = 'lastChildChanged';
    public static EVENT_CHILD_INDEX_CHANGED: RowNodeEventType = 'childIndexChanged';
    public static EVENT_ROW_INDEX_CHANGED: RowNodeEventType = 'rowIndexChanged';
    public static EVENT_EXPANDED_CHANGED: RowNodeEventType = 'expandedChanged';
    public static EVENT_HAS_CHILDREN_CHANGED: RowNodeEventType = 'hasChildrenChanged';
    public static EVENT_SELECTABLE_CHANGED: RowNodeEventType = 'selectableChanged';
    public static EVENT_UI_LEVEL_CHANGED: RowNodeEventType = 'uiLevelChanged';
    public static EVENT_HIGHLIGHT_CHANGED: RowNodeEventType = 'rowHighlightChanged';
    public static EVENT_DRAGGING_CHANGED: RowNodeEventType = 'draggingChanged';

    /** Unique ID for the node. Either provided by the application, or generated by the grid if not. */
    public id: string | undefined;

    /** If using row grouping, contains the group values for this group. */
    public groupData: { [key: string]: any | null; } | null;

    /** If using row grouping and aggregation, contains the aggregation data. */
    public aggData: any;

    /**
     * The data as provided by the application.
     * Can be `undefined` when using row grouping or during grid initialisation.
     */
    public data: TData | undefined;

    /** The parent node to this node, or empty if top level */
    public parent: RowNode<TData> | null;

    /** How many levels this node is from the top when grouping. */
    public level: number;

    /** How many levels this node is from the top when grouping in the UI (only different to `parent` when `groupRemoveSingleChildren=true`)*/
    public uiLevel: number;

    /**
     * If doing in-memory (client-side) grouping, this is the index of the group column this cell is for.
     * This will always be the same as the level, unless we are collapsing groups, i.e. `groupRemoveSingleChildren=true`.
     */
    public rowGroupIndex: number | null;

    /** `true` if this node is a group node (i.e. it has children) */
    public group: boolean | undefined;

    /** `true` if this row is getting dragged */
    public dragging: boolean;

    /** `true` if this row is a master row, part of master / detail (ie row can be expanded to show detail) */
    public master: boolean;

    /** `true` if this row is a detail row, part of master / detail (ie child row of an expanded master row)*/
    public detail: boolean;

    /** If this row is a master row that was expanded, this points to the associated detail row. */
    public detailNode: RowNode;

    /** If master detail, this contains details about the detail grid */
    public detailGridInfo: DetailGridInfo | null;

    /** `true` if this node is a group and the group is the bottom level in the tree. */
    public leafGroup: boolean;

    /** `true` if this is the first child in this group. Changes when data is sorted. */
    public firstChild: boolean;

    /** `true` if this is the last child in this group. Changes when data is sorted. */
    public lastChild: boolean;

    /** Index of this row with respect to its parent when grouping. Changes when data is sorted. */
    public childIndex: number;

    /** The current row index. If the row is filtered out or in a collapsed group, this value will be `null`. */
    public rowIndex: number | null = null;

    /** Either 'top' or 'bottom' if row pinned, otherwise `undefined` or `null`. */
    public rowPinned: RowPinnedType;

    /** When true, this row will appear in the top */
    public sticky: boolean;

    /** If row is pinned, then pinnedRowTop is used rather than rowTop */
    public stickyRowTop: number;

    /** If using quick filter, stores a string representation of the row for searching against. */
    public quickFilterAggregateText: string | null;

    /** `true` if row is a footer. Footers have `group = true` and `footer = true`. */
    public footer: boolean;

    /** The field we are grouping on eg 'country'. */
    public field: string | null;

    /** The row group column used for this group, e.g. the Country column instance. */
    public rowGroupColumn: Column | null;

    /** The key for the group eg Ireland, UK, USA */
    public key: string | null = null;

    /** Used by server-side row model. `true` if this row node is a stub. A stub is a placeholder row with loading icon while waiting from row to be loaded. */
    public stub: boolean;

    /** Used by server side row model, true if this row node failed a load */
    public failedLoad: boolean;

    /** Used by server side row model, true if this node needs refreshed by the server when in viewport */
    public __needsRefreshWhenVisible: boolean;

    /** All lowest level nodes beneath this node, no groups. */
    public allLeafChildren: RowNode<TData>[];

    /** Children of this group. If multi levels of grouping, shows only immediate children. */
    public childrenAfterGroup: RowNode<TData>[] | null;

    /** Filtered children of this group. */
    public childrenAfterFilter: RowNode<TData>[] | null;

    /** Aggregated and re-filtered children of this group. */
    public childrenAfterAggFilter: RowNode<TData>[] | null;

    /** Sorted children of this group. */
    public childrenAfterSort: RowNode<TData>[] | null;

    /** Number of children and grand children. */
    public allChildrenCount: number | null;

    /** Children mapped by the pivot columns. */
    public childrenMapped: { [key: string]: any; } | null = {};

    /** Server Side Row Model Only - the children are in an infinite cache. */
    public childStore: IServerSideStore | null;

    /** `true` if group is expanded, otherwise `false`. */
    public expanded: boolean;

    /** If using footers, reference to the footer node for this group. */
    public sibling: RowNode;

    /** The height, in pixels, of this row */
    public rowHeight: number | null | undefined;

    /** Dynamic row heights are done on demand, only when row is visible. However for row virtualisation
     * we need a row height to do the 'what rows are in viewport' maths. So we assign a row height to each
     * row based on defaults and rowHeightEstimated=true, then when the row is needed for drawing we do
     * the row height calculation and set rowHeightEstimated=false.*/
    public rowHeightEstimated: boolean;

    /**
     * This will be `true` if it has a rowIndex assigned, otherwise `false`.
     */
    public displayed: boolean = false;

    /** The row top position in pixels. */
    public rowTop: number | null = null;

    /** The top pixel for this row last time, makes sense if data set was ordered or filtered,
     * it is used so new rows can animate in from their old position. */
    public oldRowTop: number | null = null;

    /** `true` by default - can be overridden via gridOptions.isRowSelectable(rowNode) */
    public selectable = true;

    /** `true` if this node is a daemon. This means row is not part of the model. Can happen when then
     * the row is selected and then the user sets a different ID onto the node. The nodes is then
     * representing a different entity, so the selection controller, if the node is selected, takes
     * a copy where daemon=true. */
    public __daemon: boolean;

    /** Used by the value service, stores values for a particular change detection turn. */
    public __cacheData: { [colId: string]: any; };
    public __cacheVersion: number;

    /** Used by sorting service - to give deterministic sort to groups. Previously we
     * just id for this, however id is a string and had slower sorting compared to numbers. */
    public __objectId: number = RowNode.OBJECT_ID_SEQUENCE++;

    /** We cache the result of hasChildren() so that we can be aware of when it has changed, and hence
     * fire the event. Really we should just have hasChildren as an attribute and do away with hasChildren()
     * method, however that would be a breaking change. */
    private __hasChildren: boolean;

    /** When one or more Columns are using autoHeight, this keeps track of height of each autoHeight Cell,
     * indexed by the Column ID. */
    private __autoHeights?: { [id: string]: number | undefined } = {};

    /** `true` when nodes with the same id are being removed and added as part of the same batch transaction */
    public alreadyRendered = false;

    public highlighted: RowHighlightPosition | null = null;

    private hovered: boolean = false;

    private selected: boolean | undefined = false;
    private eventService: EventService | null;
    private frameworkEventListenerService: FrameworkEventListenerService | null;

    private beans: Beans;

    private checkAutoHeightsDebounced: () => void;

    constructor(beans: Beans) {
        this.beans = beans;
    }

    /**
     * Replaces the data on the `rowNode`. When this method is called, the grid will refresh the entire rendered row if it is displayed.
     */
    public setData(data: TData): void {
        this.setDataCommon(data, false);
    }

    // similar to setRowData, however it is expected that the data is the same data item. this
    // is intended to be used with Redux type stores, where the whole data can be changed. we are
    // guaranteed that the data is the same entity (so grid doesn't need to worry about the id of the
    // underlying data changing, hence doesn't need to worry about selection). the grid, upon receiving
    // dataChanged event, will refresh the cells rather than rip them all out (so user can show transitions).

    /**
     * Updates the data on the `rowNode`. When this method is called, the grid will refresh the entire rendered row if it is displayed.
     */
    public updateData(data: TData): void {
        this.setDataCommon(data, true);
    }

    private setDataCommon(data: TData, update: boolean): void {
        const oldData = this.data;

        this.data = data;
        this.beans.valueCache.onDataChanged();
        this.updateDataOnDetailNode();
        this.checkRowSelectable();
        this.resetQuickFilterAggregateText();

        const event: DataChangedEvent<TData> = this.createDataChangedEvent(data, oldData, update);

        this.dispatchLocalEvent(event);
    }

    // when we are doing master / detail, the detail node is lazy created, but then kept around.
    // so if we show / hide the detail, the same detail rowNode is used. so we need to keep the data
    // in sync, otherwise expand/collapse of the detail would still show the old values.
    private updateDataOnDetailNode(): void {
        if (this.detailNode) {
            this.detailNode.data = this.data;
        }
    }

    private createDataChangedEvent(newData: TData, oldData: TData | undefined, update: boolean): DataChangedEvent<TData> {
        return {
            type: RowNode.EVENT_DATA_CHANGED,
            node: this,
            oldData: oldData,
            newData: newData,
            update: update
        };
    }

    private createLocalRowEvent(type: RowNodeEventType): RowNodeEvent {
        return {
            type: type,
            node: this
        };
    }

    public getRowIndexString(): string {
        if (this.rowPinned === 'top') {
            return 't-' + this.rowIndex;
        }

        if (this.rowPinned === 'bottom') {
            return 'b-' + this.rowIndex;
        }

        return this.rowIndex!.toString();
    }

    private createDaemonNode(): RowNode {
        const oldNode = new RowNode(this.beans);

        // just copy the id and data, this is enough for the node to be used
        // in the selection controller (the selection controller is the only
        // place where daemon nodes can live).
        oldNode.id = this.id;
        oldNode.data = this.data;
        oldNode.__daemon = true;
        oldNode.selected = this.selected;
        oldNode.level = this.level;

        return oldNode;
    }

    public setDataAndId(data: TData, id: string | undefined): void {
        const oldNode = exists(this.id) ? this.createDaemonNode() : null;
        const oldData = this.data;

        this.data = data;
        this.updateDataOnDetailNode();
        this.setId(id);
        this.checkRowSelectable();
        this.beans.selectionService.syncInRowNode(this, oldNode);

        const event: DataChangedEvent<TData> = this.createDataChangedEvent(data, oldData, false);

        this.dispatchLocalEvent(event);
    }

    private checkRowSelectable() {
        const isRowSelectableFunc = this.beans.gridOptionsService.get('isRowSelectable');
        this.setRowSelectable(isRowSelectableFunc ? isRowSelectableFunc!(this) : true);
    }

    public setRowSelectable(newVal: boolean, suppressSelectionUpdate?: boolean) {
        if (this.selectable !== newVal) {
            this.selectable = newVal;
            if (this.eventService) {
                this.eventService.dispatchEvent(this.createLocalRowEvent(RowNode.EVENT_SELECTABLE_CHANGED));
            }

            if (suppressSelectionUpdate) { return; }

            const isGroupSelectsChildren = this.beans.gridOptionsService.get('groupSelectsChildren');
            if (isGroupSelectsChildren) {
                const selected = this.calculateSelectedFromChildren();
                this.setSelectedParams({
                    newValue: selected ?? false,
                    source: 'selectableChanged',
                });
                return;
            }

            // if row is selected but shouldn't be selectable, then deselect.
            if (this.isSelected() && !this.selectable) {
                this.setSelectedParams({
                    newValue: false,
                    source: 'selectableChanged',
                });
            }
        }
    }

    public setId(id?: string): void {
        // see if user is providing the id's
        const getRowIdFunc = this.beans.gridOptionsService.getCallback('getRowId');

        if (getRowIdFunc) {
            // if user is providing the id's, then we set the id only after the data has been set.
            // this is important for virtual pagination and viewport, where empty rows exist.
            if (this.data) {
                // we pass 'true' as we skip this level when generating keys,
                // as we don't always have the key for this level (eg when updating
                // data via transaction on SSRM, we are getting key to look up the
                // RowNode, don't have the RowNode yet, thus no way to get the current key)
                const parentKeys = this.getGroupKeys(true);
                this.id = getRowIdFunc({
                    data: this.data,
                    parentKeys: parentKeys.length > 0 ? parentKeys : undefined,
                    level: this.level
                });
                // make sure id provided doesn't start with 'row-group-' as this is reserved. also check that
                // it has 'startsWith' in case the user provided a number.
                if (this.id !== null && typeof this.id === 'string' && this.id.startsWith(RowNode.ID_PREFIX_ROW_GROUP)) {
                    console.error(`AG Grid: Row IDs cannot start with ${RowNode.ID_PREFIX_ROW_GROUP}, this is a reserved prefix for AG Grid's row grouping feature.`);
                }
                // force id to be a string
                if (this.id !== null && typeof this.id !== 'string') {
                    this.id = '' + this.id;
                }
            } else {
                // this can happen if user has set blank into the rowNode after the row previously
                // having data. this happens in virtual page row model, when data is delete and
                // the page is refreshed.
                this.id = undefined;
            }
        } else {
            this.id = id;
        }
    }

    public getGroupKeys(excludeSelf = false): string[] {
        const keys: string[] = [];

        let pointer: RowNode | null = this;
        if (excludeSelf) {
            pointer = pointer.parent;
        }
        while (pointer && pointer.level >= 0) {
            keys.push(pointer.key!);
            pointer = pointer.parent;
        }
        keys.reverse();

        return keys;
    }

    public isPixelInRange(pixel: number): boolean {
        if (!exists(this.rowTop) || !exists(this.rowHeight)) { return false; }
        return pixel >= this.rowTop && pixel < (this.rowTop + this.rowHeight);
    }

    public setFirstChild(firstChild: boolean): void {
        if (this.firstChild === firstChild) { return; }

        this.firstChild = firstChild;

        if (this.eventService) {
            this.eventService.dispatchEvent(this.createLocalRowEvent(RowNode.EVENT_FIRST_CHILD_CHANGED));
        }
    }

    public setLastChild(lastChild: boolean): void {
        if (this.lastChild === lastChild) { return; }

        this.lastChild = lastChild;

        if (this.eventService) {
            this.eventService.dispatchEvent(this.createLocalRowEvent(RowNode.EVENT_LAST_CHILD_CHANGED));
        }
    }

    public setChildIndex(childIndex: number): void {
        if (this.childIndex === childIndex) { return; }

        this.childIndex = childIndex;

        if (this.eventService) {
            this.eventService.dispatchEvent(this.createLocalRowEvent(RowNode.EVENT_CHILD_INDEX_CHANGED));
        }
    }

    public setRowTop(rowTop: number | null): void {
        this.oldRowTop = this.rowTop;

        if (this.rowTop === rowTop) { return; }

        this.rowTop = rowTop;

        if (this.eventService) {
            this.eventService.dispatchEvent(this.createLocalRowEvent(RowNode.EVENT_TOP_CHANGED));
        }

        this.setDisplayed(rowTop !== null);
    }

    public clearRowTopAndRowIndex(): void {
        this.oldRowTop = null;
        this.setRowTop(null);
        this.setRowIndex(null);
    }

    private setDisplayed(displayed: boolean): void {
        if (this.displayed === displayed) { return; }

        this.displayed = displayed;

        if (this.eventService) {
            this.eventService.dispatchEvent(this.createLocalRowEvent(RowNode.EVENT_DISPLAYED_CHANGED));
        }
    }

    public setDragging(dragging: boolean): void {
        if (this.dragging === dragging) { return; }

        this.dragging = dragging;

        if (this.eventService) {
            this.eventService.dispatchEvent(this.createLocalRowEvent(RowNode.EVENT_DRAGGING_CHANGED));
        }
    }

    public setHighlighted(highlighted: RowHighlightPosition | null): void {
        if (highlighted === this.highlighted) { return; }

        this.highlighted = highlighted;

        if (this.eventService) {
            this.eventService.dispatchEvent(this.createLocalRowEvent(RowNode.EVENT_HIGHLIGHT_CHANGED));
        }
    }

    public setHovered(hovered: boolean): void {
        if (this.hovered === hovered) { return; }

        this.hovered = hovered;
    }

    public isHovered(): boolean {
        return this.hovered;
    }

    public setAllChildrenCount(allChildrenCount: number | null): void {
        if (this.allChildrenCount === allChildrenCount) { return; }

        this.allChildrenCount = allChildrenCount;

        if (this.eventService) {
            this.eventService.dispatchEvent(this.createLocalRowEvent(RowNode.EVENT_ALL_CHILDREN_COUNT_CHANGED));
        }
    }

    public setMaster(master: boolean): void {
        if (this.master === master) { return; }

        // if changing AWAY from master, then unexpand, otherwise
        // next time it's shown it is expanded again
        if (this.master && !master) {
            this.expanded = false;
        }

        this.master = master;

        if (this.eventService) {
            this.eventService.dispatchEvent(this.createLocalRowEvent(RowNode.EVENT_MASTER_CHANGED));
        }
    }

    public setGroup(group: boolean): void {
        if (this.group === group) { return; }

        // if we used to be a group, and no longer, then close the node
        if (this.group && !group) {
            this.expanded = false;
        }

        this.group = group;
        this.updateHasChildren();

        if (this.eventService) {
            this.eventService.dispatchEvent(this.createLocalRowEvent(RowNode.EVENT_GROUP_CHANGED));
        }
    }

    /**
     * Sets the row height.
     * Call if you want to change the height initially assigned to the row.
     * After calling, you must call `api.onRowHeightChanged()` so the grid knows it needs to work out the placement of the rows. */
    public setRowHeight(rowHeight: number | undefined | null, estimated: boolean = false): void {
        this.rowHeight = rowHeight;
        this.rowHeightEstimated = estimated;

        if (this.eventService) {
            this.eventService.dispatchEvent(this.createLocalRowEvent(RowNode.EVENT_HEIGHT_CHANGED));
        }
    }

    public setRowAutoHeight(cellHeight: number | undefined, column: Column): void {
        if (!this.__autoHeights) {
            this.__autoHeights = {};
        }
        this.__autoHeights[column.getId()] = cellHeight;

        if (cellHeight != null) {
            if (this.checkAutoHeightsDebounced == null) {
                this.checkAutoHeightsDebounced = debounce(this.checkAutoHeights.bind(this), 1);
            }
            this.checkAutoHeightsDebounced();
        }
    }

    public checkAutoHeights(): void {
        let notAllPresent = false;
        let nonePresent = true;
        let newRowHeight = 0;

        const autoHeights = this.__autoHeights!;
        if (autoHeights == null) { return; }

        const displayedAutoHeightCols = this.beans.columnModel.getAllDisplayedAutoHeightCols();
        displayedAutoHeightCols.forEach(col => {
            let cellHeight = autoHeights[col.getId()];

            if (cellHeight == null) {
                // If column spanning is active a column may not provide auto height for a row if that
                // cell is not present for the given row due to a previous cell spanning over the auto height column.
                if (this.beans.columnModel.isColSpanActive()) {
                    let activeColsForRow: Column[] = [];
                    switch (col.getPinned()) {
                        case 'left':
                            activeColsForRow = this.beans.columnModel.getDisplayedLeftColumnsForRow(this);
                            break;
                        case 'right':
                            activeColsForRow = this.beans.columnModel.getDisplayedRightColumnsForRow(this);
                            break;
                        case null:
                            activeColsForRow = this.beans.columnModel.getViewportCenterColumnsForRow(this);
                            break;
                    }
                    if (activeColsForRow.includes(col)) {
                        // Column is present in the row, i.e not spanned over, but no auto height was provided so we cannot calculate the row height
                        notAllPresent = true;
                        return;
                    }
                    // Ignore this column as it is spanned over and not present in the row
                    cellHeight = -1;
                } else {
                    notAllPresent = true;
                    return;
                }
            } else {
                // At least one auto height is present
                nonePresent = false;
            }

            if (cellHeight > newRowHeight) {
                newRowHeight = cellHeight;
            }
        });

        if (notAllPresent) { return; }

        // we take min of 10, so we don't adjust for empty rows. if <10, we put to default.
        // this prevents the row starting very small when waiting for async components,
        // which would then mean the grid squashes in far to many rows (as small heights
        // means more rows fit in) which looks crap. so best ignore small values and assume
        // we are still waiting for values to render.
        if (nonePresent || newRowHeight < 10) {
            newRowHeight = this.beans.gridOptionsService.getRowHeightForNode(this).height;
        }

        if (newRowHeight == this.rowHeight) { return; }

        this.setRowHeight(newRowHeight);

        const rowModel = this.beans.rowModel as (IClientSideRowModel | IServerSideRowModel);
        if (rowModel.onRowHeightChangedDebounced) {
            rowModel.onRowHeightChangedDebounced();
        }
    }

    public setRowIndex(rowIndex: number | null): void {
        if (this.rowIndex === rowIndex) { return; }

        this.rowIndex = rowIndex;

        if (this.eventService) {
            this.eventService.dispatchEvent(this.createLocalRowEvent(RowNode.EVENT_ROW_INDEX_CHANGED));
        }
    }

    public setUiLevel(uiLevel: number): void {
        if (this.uiLevel === uiLevel) { return; }

        this.uiLevel = uiLevel;

        if (this.eventService) {
            this.eventService.dispatchEvent(this.createLocalRowEvent(RowNode.EVENT_UI_LEVEL_CHANGED));
        }
    }

    /**
     * Set the expanded state of this rowNode. Pass `true` to expand and `false` to collapse.
     */
    public setExpanded(expanded: boolean, e?: MouseEvent | KeyboardEvent): void {
        if (this.expanded === expanded) { return; }

        this.expanded = expanded;

        if (this.eventService) {
            this.eventService.dispatchEvent(this.createLocalRowEvent(RowNode.EVENT_EXPANDED_CHANGED));
        }

        const event = Object.assign({}, this.createGlobalRowEvent(Events.EVENT_ROW_GROUP_OPENED), {
            expanded,
            event: e || null
        });

        this.beans.rowNodeEventThrottle.dispatchExpanded(event);

        // when using footers we need to refresh the group row, as the aggregation
        // values jump between group and footer
        if (this.sibling) {
            this.beans.rowRenderer.refreshCells({ rowNodes: [this] });
        }
    }

    private createGlobalRowEvent(type: string): RowEvent<TData> {
        return this.beans.gridOptionsService.addGridCommonParams({
            type: type,
            node: this,
            data: this.data,
            rowIndex: this.rowIndex,
            rowPinned: this.rowPinned
        });
    }

    private dispatchLocalEvent(event: AgEvent): void {
        if (this.eventService) {
            this.eventService.dispatchEvent(event);
        }
    }

    /**
     * Replaces the value on the `rowNode` for the specified column. When complete,
     * the grid will refresh the rendered cell on the required row only.
     * **Note**: This method only fires `onCellEditRequest` when the Grid is in **Read Only** mode.
     *
     * @param colKey The column where the value should be updated
     * @param newValue The new value
     * @param eventSource The source of the event
     * @returns `true` if the value was changed, otherwise `false`.
     */
    public setDataValue(colKey: string | Column, newValue: any, eventSource?: string): boolean {
        const getColumnFromKey = () => {
            if (typeof colKey !== 'string') {
                return colKey;
            }
            // if in pivot mode, grid columns wont include primary columns
            return this.beans.columnModel.getGridColumn(colKey) ?? this.beans.columnModel.getPrimaryColumn(colKey);
        }
        // When it is done via the editors, no 'cell changed' event gets fired, as it's assumed that
        // the cell knows about the change given it's in charge of the editing.
        // this method is for the client to call, so the cell listens for the change
        // event, and also flashes the cell when the change occurs.
        const column = getColumnFromKey()!;
        const oldValue = this.getValueFromValueService(column);

        if (this.beans.gridOptionsService.get('readOnlyEdit')) {
            this.dispatchEventForSaveValueReadOnly(column, oldValue, newValue, eventSource);
            return false;
        }

        const valueChanged = this.beans.valueService.setValue(this, column, newValue, eventSource);

        this.dispatchCellChangedEvent(column, newValue, oldValue);
        this.checkRowSelectable();

        return valueChanged;
    }

    public getValueFromValueService(column: Column): any {
        // if we don't check this, then the grid will render leaf groups as open even if we are not
        // allowing the user to open leaf groups. confused? remember for pivot mode we don't allow
        // opening leaf groups, so we have to force leafGroups to be closed in case the user expanded
        // them via the API, or user user expanded them in the UI before turning on pivot mode
        const lockedClosedGroup = this.leafGroup && this.beans.columnModel.isPivotMode();

        const isOpenGroup = this.group && this.expanded && !this.footer && !lockedClosedGroup;

        // are we showing group footers
        const getGroupIncludeFooter = this.beans.gridOptionsService.getGroupIncludeFooter();
        const groupFootersEnabled = getGroupIncludeFooter({ node: this });

        // if doing footers, we normally don't show agg data at group level when group is open
        const groupAlwaysShowAggData = this.beans.gridOptionsService.get('groupSuppressBlankHeader');

        // if doing grouping and footers, we don't want to include the agg value
        // in the header when the group is open
        const ignoreAggData = (isOpenGroup && groupFootersEnabled) && !groupAlwaysShowAggData;

        const value = this.beans.valueService.getValue(column, this, false, ignoreAggData);

        return value;
    }

    private dispatchEventForSaveValueReadOnly(column: Column, oldValue: any, newValue: any, eventSource?: string): void {
        const event: CellEditRequestEvent = this.beans.gridOptionsService.addGridCommonParams({
            type: Events.EVENT_CELL_EDIT_REQUEST,
            event: null,
            rowIndex: this.rowIndex!,
            rowPinned: this.rowPinned,
            column: column,
            colDef: column.getColDef(),
            data: this.data,
            node: this,
            oldValue,
            newValue,
            value: newValue,
            source: eventSource
        });

        this.beans.eventService.dispatchEvent(event);
    }

    public setGroupValue(colKey: string | Column, newValue: any): void {
        const column = this.beans.columnModel.getGridColumn(colKey)!;

        if (missing(this.groupData)) { this.groupData = {}; }

        const columnId = column.getColId();
        const oldValue = this.groupData[columnId];

        if (oldValue === newValue) { return; }

        this.groupData[columnId] = newValue;
        this.dispatchCellChangedEvent(column, newValue, oldValue);
    }

    // sets the data for an aggregation
    public setAggData(newAggData: any): void {
        const oldAggData = this.aggData;
        this.aggData = newAggData;

        // if no event service, nobody has registered for events, so no need fire event
        if (this.eventService) {
            const eventFunc = (colId: string) => {
                const value = this.aggData ? this.aggData[colId] : undefined;
                const oldValue = oldAggData ? oldAggData[colId] : undefined;

                if (value === oldValue) { return; }

                // do a quick lookup - despite the event it's possible the column no longer exists
                const column = this.beans.columnModel.lookupGridColumn(colId)!;
                if (!column) { return; }

                this.dispatchCellChangedEvent(column, value, oldValue);
            };

            for (const key in this.aggData) {
                eventFunc(key);
            }
            for (const key in newAggData) {
                if (key in this.aggData) { continue; } // skip if already fired an event.
                eventFunc(key);
            }
        }
    }

    public updateHasChildren(): void {
        // in CSRM, the group property will be set before the childrenAfterGroup property, check both to prevent flickering
        let newValue: boolean | null = (this.group && !this.footer) || (this.childrenAfterGroup && this.childrenAfterGroup.length > 0);

        const isSsrm = this.beans.gridOptionsService.isRowModelType('serverSide');
        if (isSsrm) {
            const isTreeData = this.beans.gridOptionsService.get('treeData');
            const isGroupFunc = this.beans.gridOptionsService.get('isServerSideGroup');
            // stubs and footers can never have children, as they're grid rows. if tree data the presence of children
            // is determined by the isServerSideGroup callback, if not tree data then the rows group property will be set.
            newValue = !this.stub && !this.footer && (isTreeData ? !!isGroupFunc && isGroupFunc(this.data) : !!this.group);
        }

        if (newValue !== this.__hasChildren) {
            this.__hasChildren = !!newValue;
            if (this.eventService) {
                this.eventService.dispatchEvent(this.createLocalRowEvent(RowNode.EVENT_HAS_CHILDREN_CHANGED));
            }
        }
    }

    public hasChildren(): boolean {
        if (this.__hasChildren == null) {
            this.updateHasChildren();
        }
        return this.__hasChildren;
    }

    public isEmptyRowGroupNode(): boolean | undefined {
        return this.group && missingOrEmpty(this.childrenAfterGroup);
    }

    private dispatchCellChangedEvent(column: Column, newValue: TData, oldValue: TData): void {
        const cellChangedEvent: CellChangedEvent<TData> = {
            type: RowNode.EVENT_CELL_CHANGED,
            node: this,
            column: column,
            newValue: newValue,
            oldValue: oldValue
        };
        this.dispatchLocalEvent(cellChangedEvent);
    }

    /**
     * The first time `quickFilter` runs, the grid creates a one-off string representation of the row.
     * This string is then used for the quick filter instead of hitting each column separately.
     * When you edit, using grid editing, this string gets cleared down.
     * However if you edit without using grid editing, you will need to clear this string down for the row to be updated with the new values.
     * Otherwise new values will not work with the `quickFilter`. */
    public resetQuickFilterAggregateText(): void {
        this.quickFilterAggregateText = null;
    }

    /** Returns:
    * - `true` if the node can be expanded, i.e it is a group or master row.
    * - `false` if the node cannot be expanded
    */
    public isExpandable(): boolean {
        if (this.footer) { return false; }

        if (this.beans.columnModel.isPivotMode()) {
            // master detail and leaf groups aren't expandable in pivot mode.
            return this.hasChildren() && !this.leafGroup;
        }
        return this.hasChildren() || !!this.master;
    }

    /** Returns:
     * - `true` if node is selected,
     * - `false` if the node isn't selected
     * - `undefined` if it's partially selected (group where not all children are selected). */
    public isSelected(): boolean | undefined {
        // for footers, we just return what our sibling selected state is, as cannot select a footer
        if (this.footer) { return this.sibling.isSelected(); }

        return this.selected;
    }

    /** Perform a depth-first search of this node and its children. */
    public depthFirstSearch(callback: (rowNode: RowNode<TData>) => void): void {
        if (this.childrenAfterGroup) {
            this.childrenAfterGroup.forEach(child => child.depthFirstSearch(callback));
        }
        callback(this);
    }

    // + selectionController.calculatedSelectedForAllGroupNodes()
    public calculateSelectedFromChildren(): boolean | undefined | null {
        let atLeastOneSelected = false;
        let atLeastOneDeSelected = false;
        let atLeastOneMixed = false;

        if (!this.childrenAfterGroup?.length) {
            return this.selectable ? this.selected : null;
        }

        for (let i = 0; i < this.childrenAfterGroup.length; i++) {
            const child = this.childrenAfterGroup[i];

            let childState = child.isSelected();
            // non-selectable nodes must be calculated from their children, or ignored if no value results.
            if (!child.selectable) {
                const selectable = child.calculateSelectedFromChildren();
                if (selectable === null) {
                    continue;
                }
                childState = selectable;
            }

            switch (childState) {
                case true:
                    atLeastOneSelected = true;
                    break;
                case false:
                    atLeastOneDeSelected = true;
                    break;
                default:
                    atLeastOneMixed = true;
                    break;
            }
        }

        if (atLeastOneMixed || (atLeastOneSelected && atLeastOneDeSelected)) {
            return undefined;
        }

        if (atLeastOneSelected) {
            return true;
        }

        if (atLeastOneDeSelected) {
            return false;
        }

        if (!this.selectable) {
            return null;
        }

        return this.selected;
    }

    public setSelectedInitialValue(selected?: boolean): void {
        this.selected = selected;
    }

    public selectThisNode(newValue?: boolean, e?: Event, source: SelectionEventSourceType = 'api'): boolean {
        // we only check selectable when newValue=true (ie selecting) to allow unselecting values,
        // as selectable is dynamic, need a way to unselect rows when selectable becomes false.
        const selectionNotAllowed = !this.selectable && newValue;
        const selectionNotChanged = this.selected === newValue;

        if (selectionNotAllowed || selectionNotChanged) { return false; }

        this.selected = newValue;

        if (this.eventService) {
            this.dispatchLocalEvent(this.createLocalRowEvent(RowNode.EVENT_ROW_SELECTED));
            const sibling = this.sibling;
            if (sibling && sibling.footer) {
                sibling.dispatchLocalEvent(sibling.createLocalRowEvent(RowNode.EVENT_ROW_SELECTED));
            }
        }

        const event: RowSelectedEvent = {
            ...this.createGlobalRowEvent(Events.EVENT_ROW_SELECTED),
            event: e || null,
            source
        };

        this.beans.eventService.dispatchEvent(event);

        return true;
    }

    /**
     * Select (or deselect) the node.
     * @param newValue -`true` for selection, `false` for deselection.
     * @param clearSelection - If selecting, then passing `true` will select the node exclusively (i.e. NOT do multi select). If doing deselection, `clearSelection` has no impact.
     * @param source - Source property that will appear in the `selectionChanged` event.
     */
    public setSelected(newValue: boolean, clearSelection: boolean = false, source: SelectionEventSourceType = 'api') {
        if (typeof source === 'boolean')  {
            console.warn('AG Grid: since version v30, rowNode.setSelected() property `suppressFinishActions` has been removed, please use `gridApi.setNodesSelected()` for bulk actions, and the event `source` property for ignoring events instead.');
            return;
        }

        this.setSelectedParams({
            newValue,
            clearSelection,
            rangeSelect: false,
            source
        });
    }

    // this is for internal use only. To make calling code more readable, this is the same method as setSelected except it takes names parameters
    public setSelectedParams(params: SetSelectedParams & { event?: Event }): number {
        if (this.rowPinned) {
            console.warn('AG Grid: cannot select pinned rows');
            return 0;
        }

        if (this.id === undefined) {
            console.warn('AG Grid: cannot select node until id for node is known');
            return 0;
        }

        return this.beans.selectionService.setNodesSelected({ ...params, nodes: [this.footer ? this.sibling : this] });
    }

    /**
     * Returns:
     * - `true` if node is either pinned to the `top` or `bottom`
     * - `false` if the node isn't pinned
     */
    public isRowPinned(): boolean {
        return this.rowPinned === 'top' || this.rowPinned === 'bottom';
    }

    public isParentOfNode(potentialParent: RowNode): boolean {
        let parentNode = this.parent;

        while (parentNode) {
            if (parentNode === potentialParent) {
                return true;
            }
            parentNode = parentNode.parent;
        }

        return false;
    }

    /** Add an event listener. */
    public addEventListener(eventType: RowNodeEventType, userListener: Function): void {
        if (!this.eventService) {
            this.eventService = new EventService();
        }
        if(this.beans.frameworkOverrides.shouldWrapOutgoing && !this.frameworkEventListenerService) {
            this.eventService.setFrameworkOverrides(this.beans.frameworkOverrides);
            this.frameworkEventListenerService = new FrameworkEventListenerService(this.beans.frameworkOverrides);
        }

        const listener = this.frameworkEventListenerService?.wrap(userListener as AgEventListener) ?? userListener;
        this.eventService.addEventListener(eventType, listener as AgEventListener);
    }

    /** Remove event listener. */
    public removeEventListener(eventType: RowNodeEventType, userListener: Function): void {
        if (!this.eventService) { return; }

        const listener = this.frameworkEventListenerService?.unwrap(userListener as AgEventListener) ?? userListener;
        this.eventService.removeEventListener(eventType, listener as AgEventListener);
        if (this.eventService.noRegisteredListenersExist()) {
            this.eventService = null;
        }
    }

    public onMouseEnter(): void {
        this.dispatchLocalEvent(this.createLocalRowEvent(RowNode.EVENT_MOUSE_ENTER));
    }

    public onMouseLeave(): void {
        this.dispatchLocalEvent(this.createLocalRowEvent(RowNode.EVENT_MOUSE_LEAVE));
    }

    public getFirstChildOfFirstChild(rowGroupColumn: Column | null): RowNode | null {
        let currentRowNode: RowNode = this;
        let isCandidate = true;
        let foundFirstChildPath = false;
        let nodeToSwapIn: RowNode | null = null;

        // if we are hiding groups, then if we are the first child, of the first child,
        // all the way up to the column we are interested in, then we show the group cell.
        while (isCandidate && !foundFirstChildPath) {
            const parentRowNode = currentRowNode.parent!;
            const firstChild = exists(parentRowNode) && currentRowNode.firstChild;

            if (firstChild) {
                if (parentRowNode.rowGroupColumn === rowGroupColumn) {
                    foundFirstChildPath = true;
                    nodeToSwapIn = parentRowNode;
                }
            } else {
                isCandidate = false;
            }

            currentRowNode = parentRowNode;
        }

        return foundFirstChildPath ? nodeToSwapIn : null;
    }

    /**
     * Returns:
     * - `true` if the node is a full width cell
     * - `false` if the node is not a full width cell
     */
    public isFullWidthCell(): boolean {
        if (this.detail) { return true; }

        const isFullWidthCellFunc = this.beans.gridOptionsService.getCallback('isFullWidthRow');
        return isFullWidthCellFunc ? isFullWidthCellFunc({ rowNode: this }) : false;
    }

    /**
     * Returns the route of the row node. If the Row Node is a group, it returns the route to that Row Node.
     * If the Row Node is not a group, it returns `undefined`.
     */
    public getRoute(): string[] | undefined {
        if (this.key == null) { return; }

        const res: string[] = [];

        let pointer: RowNode = this;

        while (pointer.key != null) {
            res.push(pointer.key);
            pointer = pointer.parent!;
        }

        return res.reverse();
    }

    public createFooter(): void {
        // only create footer node once, otherwise we have daemons and
        // the animate screws up with the daemons hanging around
        if (this.sibling) { return; }

        // we don't copy these properties as they cause the footer node
        // to have properties which should be unique to the row.
        const ignoredProperties = new Set([
            'eventService',
            '__objectId',
            'sticky',
        ]);
        const footerNode = new RowNode(this.beans);

        Object.keys(this).forEach( key => {
            if (ignoredProperties.has(key)) { return; }
            (footerNode as any)[key] = (this as any)[key];
        });

        footerNode.footer = true;
        footerNode.setRowTop(null);
        footerNode.setRowIndex(null);

        // manually set oldRowTop to null so we discard any
        // previous information about its position.
        footerNode.oldRowTop = null;

        footerNode.id = 'rowGroupFooter_' + this.id;

        // get both header and footer to reference each other as siblings. this is never undone,
        // only overwritten. so if a group is expanded, then contracted, it will have a ghost
        // sibling - but that's fine, as we can ignore this if the header is contracted.
        footerNode.sibling = this;
        this.sibling = footerNode;
    }

    // Only used by SSRM. In CSRM this is never used as footers should always be present for
    // the purpose of exporting collapsed groups. In SSRM it is not possible to export collapsed
    // groups anyway, so can destroy footers.
    public destroyFooter(): void {
        if (!this.sibling) { return; }

        this.sibling.setRowTop(null);
        this.sibling.setRowIndex(null);

        this.sibling = undefined as any;
    }
}
